//go:build linux

package iptables

import (
	"context"
	"fmt"
	"os"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/containerd/log"
	dbus "github.com/godbus/dbus/v5"
	"github.com/pkg/errors"
)

const (
	dbusInterface   = "org.fedoraproject.FirewallD1"
	dbusPath        = "/org/fedoraproject/FirewallD1"
	dbusConfigPath  = "/org/fedoraproject/FirewallD1/config"
	dockerZone      = "docker"
	dockerFwdPolicy = "docker-forwarding"
)

// Conn is a connection to firewalld dbus endpoint.
type Conn struct {
	sysconn    *dbus.Conn
	sysObj     dbus.BusObject
	sysConfObj dbus.BusObject
	signal     chan *dbus.Signal
}

var (
	connection *Conn

	firewalldRunning bool // is Firewalld service running
	// Time of the last firewalld reload.
	firewalldReloadedAt atomic.Value
	// Mutex to serialise firewalld reload callbacks.
	firewalldReloadMu sync.Mutex
	onReloaded        []*func() // callbacks when Firewalld has been reloaded
)

// UsingFirewalld returns true if iptables rules will be applied via firewalld's
// passthrough interface.
func UsingFirewalld() bool {
	_ = initCheck()
	return firewalldRunning
}

// FirewalldReloadedAt returns the time at which the daemon last completed a
// firewalld reload, or a zero-valued time.Time if it has not been reloaded
// since the daemon started.
func FirewalldReloadedAt() time.Time {
	val := firewalldReloadedAt.Load()
	if val == nil {
		return time.Time{}
	}
	return val.(time.Time)
}

// firewalldInit initializes firewalld management code.
func firewalldInit() error {
	var err error

	// DOCKER_TEST_NO_FIREWALLD is used by integration tests to disable firewalld integration to make sure that the
	// daemon started by the 'test-integration' script won't recreate iptables / nftables rules upon receiving the
	// firewalld reload signal, otherwise it'll race against the daemon-under-test started by networking integration
	// tests. This is an internal implementation detail and users shall never rely on this.
	if disable := os.Getenv("DOCKER_TEST_NO_FIREWALLD"); disable != "" {
		return nil
	}

	if connection, err = newConnection(); err != nil {
		return fmt.Errorf("Failed to connect to D-Bus system bus: %v", err)
	}
	firewalldRunning = checkRunning()
	if !firewalldRunning {
		connection.sysconn.Close()
		connection = nil
	}
	if connection != nil {
		go signalHandler()
		zoneAdded, err := setupDockerZone()
		if err != nil {
			return err
		}
		policyAdded, policyAddErr := setupDockerForwardingPolicy()
		if policyAddErr != nil {
			// Log the error, but still reload firewalld if necessary.
			log.G(context.TODO()).WithError(policyAddErr).Warnf("Firewalld: failed to add policy %s", dockerFwdPolicy)
		}
		if zoneAdded || policyAdded {
			// Reload for changes to take effect.
			if err := connection.sysObj.Call(dbusInterface+".reload", 0).Err; err != nil {
				return err
			}
		}
	}

	return nil
}

// newConnection establishes a connection to the system bus.
func newConnection() (*Conn, error) {
	c := &Conn{}

	var err error
	c.sysconn, err = dbus.SystemBus()
	if err != nil {
		return nil, err
	}

	// This never fails, even if the service is not running atm.
	c.sysObj = c.sysconn.Object(dbusInterface, dbusPath)
	c.sysConfObj = c.sysconn.Object(dbusInterface, dbusConfigPath)

	rule := fmt.Sprintf("type='signal',path='%s',interface='%s',sender='%s',member='Reloaded'", dbusPath, dbusInterface, dbusInterface)
	c.sysconn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule)

	rule = fmt.Sprintf("type='signal',interface='org.freedesktop.DBus',member='NameOwnerChanged',path='/org/freedesktop/DBus',sender='org.freedesktop.DBus',arg0='%s'", dbusInterface)
	c.sysconn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, rule)

	c.signal = make(chan *dbus.Signal, 10)
	c.sysconn.Signal(c.signal)
	return c, nil
}

func signalHandler() {
	for signal := range connection.signal {
		switch {
		case strings.Contains(signal.Name, "NameOwnerChanged"):
			firewalldRunning = checkRunning()
			dbusConnectionChanged(signal.Body)

		case strings.Contains(signal.Name, "Reloaded"):
			reloaded()
		}
	}
}

func dbusConnectionChanged(args []any) {
	name := args[0].(string)
	oldOwner := args[1].(string)
	newOwner := args[2].(string)

	if name != dbusInterface {
		return
	}

	if newOwner != "" {
		connectionEstablished()
	} else if oldOwner != "" {
		connectionLost()
	}
}

func connectionEstablished() {
	reloaded()
}

func connectionLost() {
	// Doesn't do anything for now. Libvirt also doesn't react to this.
}

// call all callbacks
func reloaded() {
	firewalldReloadMu.Lock()
	defer firewalldReloadMu.Unlock()
	for _, pf := range onReloaded {
		(*pf)()
	}
	firewalldReloadedAt.Store(time.Now())
}

// OnReloaded add callback
func OnReloaded(callback func()) {
	for _, pf := range onReloaded {
		if pf == &callback {
			return
		}
	}
	onReloaded = append(onReloaded, &callback)
}

// Call some remote method to see whether the service is actually running.
func checkRunning() bool {
	if connection == nil {
		return false
	}
	var zone string
	err := connection.sysObj.Call(dbusInterface+".getDefaultZone", 0).Store(&zone)
	return err == nil
}

// passthrough method simply passes args through to iptables/ip6tables
func passthrough(ipv IPVersion, args ...string) ([]byte, error) {
	var output string
	log.G(context.TODO()).Debugf("Firewalld passthrough: %s, %s", ipv, args)
	if err := connection.sysObj.Call(dbusInterface+".direct.passthrough", 0, ipv, args).Store(&output); err != nil {
		return nil, err
	}
	return []byte(output), nil
}

// firewalldZone holds the firewalld zone settings.
//
// Documented in https://firewalld.org/documentation/man-pages/firewalld.dbus.html#FirewallD1.zone
type firewalldZone struct {
	version            string
	name               string
	description        string
	unused             bool
	target             string
	services           []string
	ports              [][]any
	icmpBlocks         []string
	masquerade         bool
	forwardPorts       [][]any
	interfaces         []string
	sourceAddresses    []string
	richRules          []string
	protocols          []string
	sourcePorts        [][]any
	icmpBlockInversion bool
}

// settings returns the firewalldZone struct as an interface slice, which can be
// passed to "org.fedoraproject.FirewallD1.config.addZone". Note that 'addZone',
// which is deprecated, requires this whole struct. Its replacement, 'addZone2'
// (introduced in firewalld 0.9.0) accepts a dictionary where only non-default
// values need to be specified.
func (z firewalldZone) settings() []any {
	return []any{
		z.version,
		z.name,
		z.description,
		z.unused,
		z.target,
		z.services,
		z.ports,
		z.icmpBlocks,
		z.masquerade,
		z.forwardPorts,
		z.interfaces,
		z.sourceAddresses,
		z.richRules,
		z.protocols,
		z.sourcePorts,
		z.icmpBlockInversion,
	}
}

// setupDockerZone creates a zone called docker in firewalld which includes docker interfaces to allow
// container networking. The bool return value is true if a firewalld reload is required.
func setupDockerZone() (bool, error) {
	var zones []string
	// Check if zone exists
	if err := connection.sysObj.Call(dbusInterface+".zone.getZones", 0).Store(&zones); err != nil {
		return false, err
	}
	if contains(zones, dockerZone) {
		log.G(context.TODO()).Infof("Firewalld: %s zone already exists, returning", dockerZone)
		return false, nil
	}
	log.G(context.TODO()).Debugf("Firewalld: creating %s zone", dockerZone)

	// Permanent
	dz := firewalldZone{
		version:     "1.0",
		name:        dockerZone,
		description: "zone for docker bridge network interfaces",
		target:      "ACCEPT",
	}
	if err := connection.sysConfObj.Call(dbusInterface+".config.addZone", 0, dockerZone, dz.settings()).Err; err != nil {
		return false, err
	}
	return true, nil
}

// setupDockerForwardingPolicy creates a policy to allow forwarding to anywhere to the docker
// zone (where packets will be dealt with by docker's usual/non-firewalld configuration).
// The bool return value is true if a firewalld reload is required.
func setupDockerForwardingPolicy() (bool, error) {
	// https://firewalld.org/documentation/man-pages/firewalld.dbus.html#FirewallD1.config
	policy := map[string]any{
		"version":       "1.0",
		"description":   "allow forwarding to the docker zone",
		"ingress_zones": []string{"ANY"},
		"egress_zones":  []string{dockerZone},
		"target":        "ACCEPT",
	}
	if err := connection.sysConfObj.Call(dbusInterface+".config.addPolicy", 0, dockerFwdPolicy, policy).Err; err != nil {
		var derr dbus.Error
		if errors.As(err, &derr) {
			if derr.Name == dbusInterface+".Exception" && strings.HasPrefix(err.Error(), "NAME_CONFLICT") {
				log.G(context.TODO()).Debugf("Firewalld: %s policy already exists", dockerFwdPolicy)
				return false, nil
			}
			if derr.Name == dbus.ErrMsgUnknownMethod.Name {
				log.G(context.TODO()).Debugf("Firewalld: addPolicy %s: unknown method", dockerFwdPolicy)
				return false, nil
			}
		}
		return false, err
	}
	log.G(context.TODO()).Infof("Firewalld: created %s policy", dockerFwdPolicy)
	return true, nil
}

// AddInterfaceFirewalld adds the interface to the trusted zone. It is a
// no-op if firewalld is not running.
func AddInterfaceFirewalld(intf string) error {
	if !firewalldRunning {
		return nil
	}

	var intfs []string
	// Check if interface is already added to the zone
	if err := connection.sysObj.Call(dbusInterface+".zone.getInterfaces", 0, dockerZone).Store(&intfs); err != nil {
		return err
	}
	// Return if interface is already part of the zone
	if contains(intfs, intf) {
		log.G(context.TODO()).Infof("Firewalld: interface %s already part of %s zone, returning", intf, dockerZone)
		return nil
	}

	log.G(context.TODO()).Debugf("Firewalld: adding %s interface to %s zone", intf, dockerZone)
	// Runtime
	if err := connection.sysObj.Call(dbusInterface+".zone.addInterface", 0, dockerZone, intf).Err; err != nil {
		return err
	}
	return nil
}

// DelInterfaceFirewalld removes the interface from the trusted zone It is a
// no-op if firewalld is not running.
func DelInterfaceFirewalld(intf string) error {
	if !firewalldRunning {
		return nil
	}

	var intfs []string
	// Check if interface is part of the zone
	if err := connection.sysObj.Call(dbusInterface+".zone.getInterfaces", 0, dockerZone).Store(&intfs); err != nil {
		return err
	}
	// Remove interface if it exists
	if !contains(intfs, intf) {
		return &interfaceNotFound{fmt.Errorf("firewalld: interface %q not found in %s zone", intf, dockerZone)}
	}

	log.G(context.TODO()).Debugf("Firewalld: removing %s interface from %s zone", intf, dockerZone)
	// Runtime
	if err := connection.sysObj.Call(dbusInterface+".zone.removeInterface", 0, dockerZone, intf).Err; err != nil {
		return err
	}
	return nil
}

type interfaceNotFound struct{ error }

func (interfaceNotFound) NotFound() {}

func contains(list []string, val string) bool {
	for _, v := range list {
		if v == val {
			return true
		}
	}
	return false
}
